Geen omschrijving

[id].tsx 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. import { useEffect, useMemo, useState } from 'react';
  2. import {
  3. Alert,
  4. Image,
  5. KeyboardAvoidingView,
  6. Platform,
  7. Pressable,
  8. ScrollView,
  9. StyleSheet,
  10. TextInput,
  11. View,
  12. } from 'react-native';
  13. import * as ImagePicker from 'expo-image-picker';
  14. import { ResizeMode, Video } from 'expo-av';
  15. import { useLocalSearchParams, useRouter } from 'expo-router';
  16. import { ThemedButton } from '@/components/themed-button';
  17. import { IconButton } from '@/components/icon-button';
  18. import { ThemedText } from '@/components/themed-text';
  19. import { ThemedView } from '@/components/themed-view';
  20. import { ZoomImageModal } from '@/components/zoom-image-modal';
  21. import { Colors } from '@/constants/theme';
  22. import { useColorScheme } from '@/hooks/use-color-scheme';
  23. import { useTranslation } from '@/localization/i18n';
  24. import { dbPromise, initCoreTables } from '@/services/db';
  25. type FieldRow = {
  26. id: number;
  27. name: string | null;
  28. area_ha: number | null;
  29. notes: string | null;
  30. photo_uri: string | null;
  31. created_at: string | null;
  32. updated_at: string | null;
  33. };
  34. type FieldMediaRow = {
  35. uri: string | null;
  36. };
  37. export default function FieldDetailScreen() {
  38. const { t } = useTranslation();
  39. const router = useRouter();
  40. const { id } = useLocalSearchParams<{ id?: string | string[] }>();
  41. const fieldId = Number(Array.isArray(id) ? id[0] : id);
  42. const theme = useColorScheme() ?? 'light';
  43. const palette = Colors[theme];
  44. const [loading, setLoading] = useState(true);
  45. const [status, setStatus] = useState('');
  46. const [saving, setSaving] = useState(false);
  47. const [showSaved, setShowSaved] = useState(false);
  48. const [name, setName] = useState('');
  49. const [areaHa, setAreaHa] = useState('');
  50. const [notes, setNotes] = useState('');
  51. const [mediaUris, setMediaUris] = useState<string[]>([]);
  52. const [activeUri, setActiveUri] = useState<string | null>(null);
  53. const [errors, setErrors] = useState<{ name?: string; area?: string }>({});
  54. const [zoomUri, setZoomUri] = useState<string | null>(null);
  55. useEffect(() => {
  56. let isActive = true;
  57. async function loadField() {
  58. if (!Number.isFinite(fieldId)) {
  59. setStatus(t('fields.empty'));
  60. setLoading(false);
  61. return;
  62. }
  63. try {
  64. await initCoreTables();
  65. const db = await dbPromise;
  66. const rows = await db.getAllAsync<FieldRow>(
  67. 'SELECT id, name, area_ha, notes, photo_uri, created_at, updated_at FROM fields WHERE id = ? LIMIT 1;',
  68. fieldId
  69. );
  70. if (!isActive) return;
  71. const field = rows[0];
  72. if (!field) {
  73. setStatus(t('fields.empty'));
  74. setLoading(false);
  75. return;
  76. }
  77. setName(field.name ?? '');
  78. setAreaHa(field.area_ha !== null ? String(field.area_ha) : '');
  79. setNotes(field.notes ?? '');
  80. const mediaRows = await db.getAllAsync<FieldMediaRow>(
  81. 'SELECT uri FROM field_media WHERE field_id = ? ORDER BY created_at ASC;',
  82. fieldId
  83. );
  84. const media = uniqueMediaUris([
  85. ...(mediaRows.map((row) => row.uri).filter(Boolean) as string[]),
  86. ...(normalizeMediaUri(field.photo_uri) ? [normalizeMediaUri(field.photo_uri) as string] : []),
  87. ]);
  88. setMediaUris(media);
  89. setActiveUri(media[0] ?? normalizeMediaUri(field.photo_uri));
  90. setStatus('');
  91. } catch (error) {
  92. if (isActive) setStatus(`Error: ${String(error)}`);
  93. } finally {
  94. if (isActive) setLoading(false);
  95. }
  96. }
  97. loadField();
  98. return () => {
  99. isActive = false;
  100. };
  101. }, [fieldId, t]);
  102. const inputStyle = [
  103. styles.input,
  104. {
  105. borderColor: palette.border,
  106. backgroundColor: palette.input,
  107. color: palette.text,
  108. },
  109. ];
  110. async function handleUpdate() {
  111. if (!Number.isFinite(fieldId)) return;
  112. const trimmedName = name.trim();
  113. const area = areaHa.trim() ? Number(areaHa) : null;
  114. const nextErrors: { name?: string; area?: string } = {};
  115. if (!trimmedName) {
  116. nextErrors.name = t('fields.nameRequired');
  117. }
  118. if (areaHa.trim() && !Number.isFinite(area)) {
  119. nextErrors.area = t('fields.areaInvalid');
  120. }
  121. setErrors(nextErrors);
  122. if (Object.keys(nextErrors).length > 0) {
  123. return;
  124. }
  125. try {
  126. setSaving(true);
  127. const db = await dbPromise;
  128. const now = new Date().toISOString();
  129. const primaryUri = mediaUris[0] ?? normalizeMediaUri(activeUri);
  130. await db.runAsync(
  131. 'UPDATE fields SET name = ?, area_ha = ?, notes = ?, photo_uri = ?, updated_at = ? WHERE id = ?;',
  132. trimmedName,
  133. area,
  134. notes.trim() || null,
  135. primaryUri ?? null,
  136. now,
  137. fieldId
  138. );
  139. await db.runAsync('DELETE FROM field_media WHERE field_id = ?;', fieldId);
  140. const mediaToInsert = uniqueMediaUris([
  141. ...mediaUris,
  142. ...(normalizeMediaUri(activeUri) ? [normalizeMediaUri(activeUri) as string] : []),
  143. ]);
  144. for (const uri of mediaToInsert) {
  145. await db.runAsync(
  146. 'INSERT INTO field_media (field_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  147. fieldId,
  148. uri,
  149. isVideoUri(uri) ? 'video' : 'image',
  150. now
  151. );
  152. }
  153. setStatus(t('fields.saved'));
  154. setShowSaved(true);
  155. setTimeout(() => {
  156. setShowSaved(false);
  157. setStatus('');
  158. }, 1800);
  159. } catch (error) {
  160. setStatus(`Error: ${String(error)}`);
  161. } finally {
  162. setSaving(false);
  163. }
  164. }
  165. function confirmDelete() {
  166. Alert.alert(
  167. t('fields.deleteTitle'),
  168. t('fields.deleteMessage'),
  169. [
  170. { text: t('fields.cancel'), style: 'cancel' },
  171. {
  172. text: t('fields.delete'),
  173. style: 'destructive',
  174. onPress: async () => {
  175. const db = await dbPromise;
  176. await db.runAsync('DELETE FROM field_media WHERE field_id = ?;', fieldId);
  177. await db.runAsync('DELETE FROM fields WHERE id = ?;', fieldId);
  178. router.back();
  179. },
  180. },
  181. ]
  182. );
  183. }
  184. const previewUri = useMemo(() => normalizeMediaUri(activeUri), [activeUri]);
  185. return (
  186. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  187. <KeyboardAvoidingView
  188. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  189. style={styles.keyboardAvoid}>
  190. <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
  191. <ThemedText type="title">{t('fields.edit')}</ThemedText>
  192. {status && !showSaved ? <ThemedText>{status}</ThemedText> : null}
  193. <ThemedText>
  194. {t('fields.name')}
  195. <ThemedText style={styles.requiredMark}> *</ThemedText>
  196. </ThemedText>
  197. <TextInput
  198. value={name}
  199. onChangeText={(value) => {
  200. setName(value);
  201. if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }));
  202. }}
  203. placeholder={t('fields.name')}
  204. placeholderTextColor={palette.placeholder}
  205. style={inputStyle}
  206. />
  207. {errors.name ? <ThemedText style={styles.errorText}>{errors.name}</ThemedText> : null}
  208. <ThemedText>{t('fields.area')}</ThemedText>
  209. <TextInput
  210. value={areaHa}
  211. onChangeText={(value) => {
  212. setAreaHa(value);
  213. if (errors.area) setErrors((prev) => ({ ...prev, area: undefined }));
  214. }}
  215. placeholder={t('fields.areaPlaceholder')}
  216. placeholderTextColor={palette.placeholder}
  217. style={inputStyle}
  218. keyboardType="decimal-pad"
  219. />
  220. {errors.area ? <ThemedText style={styles.errorText}>{errors.area}</ThemedText> : null}
  221. <ThemedText>{t('fields.notes')}</ThemedText>
  222. <TextInput
  223. value={notes}
  224. onChangeText={setNotes}
  225. placeholder={t('fields.notesPlaceholder')}
  226. placeholderTextColor={palette.placeholder}
  227. style={inputStyle}
  228. multiline
  229. />
  230. <ThemedText>{t('fields.addMedia')}</ThemedText>
  231. {previewUri ? (
  232. isVideoUri(previewUri) ? (
  233. <Video
  234. source={{ uri: previewUri }}
  235. style={styles.mediaPreview}
  236. useNativeControls
  237. resizeMode={ResizeMode.CONTAIN}
  238. />
  239. ) : (
  240. <Pressable onPress={() => setZoomUri(previewUri)}>
  241. <Image source={{ uri: previewUri }} style={styles.mediaPreview} resizeMode="contain" />
  242. </Pressable>
  243. )
  244. ) : (
  245. <ThemedText style={styles.photoPlaceholder}>{t('fields.noPhoto')}</ThemedText>
  246. )}
  247. {mediaUris.length > 0 ? (
  248. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
  249. {mediaUris.map((uri) => (
  250. <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
  251. {isVideoUri(uri) ? (
  252. <View style={styles.videoThumb}>
  253. <ThemedText style={styles.videoThumbText}>▶</ThemedText>
  254. </View>
  255. ) : (
  256. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  257. )}
  258. <Pressable
  259. style={styles.mediaRemove}
  260. onPress={(event) => {
  261. event.stopPropagation();
  262. setMediaUris((prev) => {
  263. const next = prev.filter((item) => item !== uri);
  264. setActiveUri((current) => (current === uri ? next[0] ?? null : current));
  265. return next;
  266. });
  267. }}>
  268. <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
  269. </Pressable>
  270. </Pressable>
  271. ))}
  272. </ScrollView>
  273. ) : null}
  274. <View style={styles.photoRow}>
  275. <ThemedButton
  276. title={t('fields.pickFromGallery')}
  277. onPress={() =>
  278. handlePickMedia((uris) => {
  279. if (uris.length === 0) return;
  280. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  281. setActiveUri((prev) => prev ?? uris[0]);
  282. })
  283. }
  284. variant="secondary"
  285. />
  286. <ThemedButton
  287. title={t('fields.takeMedia')}
  288. onPress={() =>
  289. handleTakeMedia((uri) => {
  290. if (!uri) return;
  291. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  292. setActiveUri((prev) => prev ?? uri);
  293. })
  294. }
  295. variant="secondary"
  296. />
  297. </View>
  298. <View style={styles.actions}>
  299. <IconButton
  300. name="trash"
  301. onPress={confirmDelete}
  302. accessibilityLabel={t('fields.delete')}
  303. variant="danger"
  304. />
  305. <View style={styles.updateGroup}>
  306. {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('fields.saved')}</ThemedText> : null}
  307. <ThemedButton
  308. title={saving ? t('fields.saving') : t('fields.update')}
  309. onPress={handleUpdate}
  310. disabled={saving}
  311. />
  312. </View>
  313. </View>
  314. </ScrollView>
  315. </KeyboardAvoidingView>
  316. <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
  317. </ThemedView>
  318. );
  319. }
  320. async function handlePickMedia(onAdd: (uris: string[]) => void) {
  321. const result = await ImagePicker.launchImageLibraryAsync({
  322. mediaTypes: getMediaTypes(),
  323. quality: 1,
  324. allowsMultipleSelection: true,
  325. selectionLimit: 0,
  326. });
  327. if (result.canceled) return;
  328. const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
  329. if (uris.length === 0) return;
  330. onAdd(uris);
  331. }
  332. async function handleTakeMedia(onAdd: (uri: string | null) => void) {
  333. const permission = await ImagePicker.requestCameraPermissionsAsync();
  334. if (!permission.granted) {
  335. return;
  336. }
  337. const result = await ImagePicker.launchCameraAsync({
  338. mediaTypes: getMediaTypes(),
  339. quality: 1,
  340. });
  341. if (result.canceled) return;
  342. const asset = result.assets[0];
  343. onAdd(asset.uri);
  344. }
  345. function getMediaTypes() {
  346. const mediaType = (ImagePicker as {
  347. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  348. }).MediaType;
  349. const imageType = mediaType?.Image ?? mediaType?.Images;
  350. const videoType = mediaType?.Video ?? mediaType?.Videos;
  351. if (imageType && videoType) {
  352. return [imageType, videoType];
  353. }
  354. return imageType ?? videoType ?? ['images', 'videos'];
  355. }
  356. function isVideoUri(uri: string) {
  357. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  358. }
  359. function normalizeMediaUri(uri?: string | null) {
  360. if (typeof uri !== 'string') return null;
  361. const trimmed = uri.trim();
  362. return trimmed ? trimmed : null;
  363. }
  364. function uniqueMediaUris(uris: string[]) {
  365. const seen = new Set<string>();
  366. const result: string[] = [];
  367. for (const uri of uris) {
  368. if (!uri || seen.has(uri)) continue;
  369. seen.add(uri);
  370. result.push(uri);
  371. }
  372. return result;
  373. }
  374. const styles = StyleSheet.create({
  375. container: {
  376. flex: 1,
  377. },
  378. keyboardAvoid: {
  379. flex: 1,
  380. },
  381. content: {
  382. padding: 16,
  383. gap: 10,
  384. paddingBottom: 40,
  385. },
  386. input: {
  387. borderRadius: 10,
  388. borderWidth: 1,
  389. paddingHorizontal: 12,
  390. paddingVertical: 10,
  391. fontSize: 15,
  392. },
  393. requiredMark: {
  394. color: '#C0392B',
  395. fontWeight: '700',
  396. },
  397. errorText: {
  398. color: '#C0392B',
  399. fontSize: 12,
  400. },
  401. mediaPreview: {
  402. width: '100%',
  403. height: 220,
  404. borderRadius: 12,
  405. backgroundColor: '#1C1C1C',
  406. },
  407. photoRow: {
  408. flexDirection: 'row',
  409. gap: 8,
  410. },
  411. actions: {
  412. marginTop: 12,
  413. flexDirection: 'row',
  414. justifyContent: 'space-between',
  415. alignItems: 'center',
  416. gap: 10,
  417. },
  418. photoPlaceholder: {
  419. opacity: 0.6,
  420. },
  421. mediaStrip: {
  422. marginTop: 6,
  423. },
  424. mediaChip: {
  425. width: 72,
  426. height: 72,
  427. borderRadius: 10,
  428. marginRight: 8,
  429. overflow: 'hidden',
  430. backgroundColor: '#E6E1D4',
  431. alignItems: 'center',
  432. justifyContent: 'center',
  433. },
  434. mediaThumb: {
  435. width: '100%',
  436. height: '100%',
  437. },
  438. videoThumb: {
  439. width: '100%',
  440. height: '100%',
  441. backgroundColor: '#1C1C1C',
  442. alignItems: 'center',
  443. justifyContent: 'center',
  444. },
  445. videoThumbText: {
  446. color: '#FFFFFF',
  447. fontSize: 18,
  448. fontWeight: '700',
  449. },
  450. mediaRemove: {
  451. position: 'absolute',
  452. top: 4,
  453. right: 4,
  454. width: 18,
  455. height: 18,
  456. borderRadius: 9,
  457. backgroundColor: 'rgba(0,0,0,0.6)',
  458. alignItems: 'center',
  459. justifyContent: 'center',
  460. },
  461. mediaRemoveText: {
  462. color: '#FFFFFF',
  463. fontSize: 12,
  464. lineHeight: 14,
  465. fontWeight: '700',
  466. },
  467. updateGroup: {
  468. flexDirection: 'row',
  469. alignItems: 'center',
  470. gap: 8,
  471. },
  472. inlineToastText: {
  473. fontWeight: '700',
  474. fontSize: 12,
  475. },
  476. });